I decided to write my own minimal Static Site Generator for this Neocities site rather than use an established system like Hugo or Gatsby (Although, as a React supremacist, I was tempted by Gatsby).
Both options mentioned are robust and perfectly capable, but were a bit bulky for my needs. Considering my current state of having too much time on my hands, I decided to jump on the chance to learn something new - I've never used or written a SSG before.
Javascript is the programming language that I'm most comfortable with, so I chose to write my SSG engine via Node.
This site houses a blog that's frequently updated, and I needed a way to generate HTML pages from markdown files to avoid laboring over hand-written HTML for each post. I decided to use Showdown to handle the parsing. It's intuitive, and requires just two lines of code to get the ball rolling.
Templating
To avoid copy pasting chunks of markup throughout my pages, I utilize a templating language for Node called Pug. Being a javascript junkie, I had initially accomplished this templating using JS template literals. While it served its purpose, long template literals can be cumbersome to manage, and I quickly acquired a headache updating them. Pug, looking a bit like a twisted love child of Python and HTML, offers a much cleaner solution. It also allows for inline JS execution, which is quite handy.
Chunks of Pug can be plugged into other Pug files for reuse via a system called "mixins". This feature is particularly useful for my Navbar, which renders on nearly every page of my site.
//nav.pug
mixin navWithHeader
h1 Sleepy dev π
ul(id="nav")
li
a(href="index.html") π Home
li
a(href="about.html") π§Έ About
li
a(href="thoughts.html") π Blog
li
a(href="microblog.html") π Microblog
li
a(href="misc.html") π Misc.
li
a(href="guestbook.html") π Guestbook
Below is an example of a Pug file that I use to generate my thoughts.html
page, which renders a list of all of my blog posts in reverse chronological order.
//thoughts.pug
include nav.pug
doctype html
html(lang="en")
head
meta(charset='utf-8')
title="Sleepydev"
link(rel="stylesheet" href="index.css" type="text/css")
link(rel="icon" type="image/x-icon" href="assets/favicon.png")
body
+navWithHeader
- const sortedPosts = postData.sort((x, y) => new Date(y.pubDate) - new Date(x.pubDate))
ul
each post in sortedPosts
li
a(href=`posts/${encodeURIComponent(post.title)}.html`)= post.title
a(href="feed.xml" target="_blank") π₯ Subscribe via RSS
The (relevant) file structure of my SSG looks something like this: src/
houses my generation logic and layout files, posts/
holds my blog post markdown files, and site/
is where the generated static site files live. site/
is what's uploaded to Neocities.
src/
ββ thoughts.pug
ββ postMetaData.json
ββ post.pug
ββ index.pug
ββ nav.pug
ββ index.js
ββ rss.js
posts/
ββ blogPost.md
site/
ββ assets/
ββ posts/
β ββblogPost.html
ββ index.html
ββ index.css
ββ feed.xml
Generating files
The following JS block loops over each markdown file I have in my /posts
folder, converts each to a string of HTML , and passes that HTML chunk into a Pug render function to be consumed by a Pug template file that wraps the chunk in layout code. The final HTML string is written to a .html
file in the /site
folder.
const converter = new showdown.Converter();
fs.readdirSync("./posts/").forEach((filePath) => {
const postHtml = converter.makeHtml(fs.readFileSync(`./posts/${filePath}`, 'utf8'));
const pageHtml = pug.renderFile('src/post.pug', { postHtml: postHtml });
const fileName = filePath.split('.')[0];
fs.writeFileSync(`site/posts/${fileName}.html`, pageHtml);
});
Post "metadata" is generated automatically with each build. New articles are assigned a publication date of the current date. I could also assign a publication date to each article manually within the markdown, but this solution is working well enough for now.
postFilePaths.forEach((file) => {
const fileName = file.split('.')[0];
const postMetaDataExists = postMetaData.find((data) => data.title === fileName)
if (!postMetaDataExists) {
postMetaData.push({ title: fileName, pubDate: new Date() });
}
fs.writeFileSync(`src/postData.json`, JSON.stringify(postMetaData));
});
My blog features an rss feed, which is generated with the help of an rss Node package. In hindsight, my feed is simple enough that I could forgo usage of this library; but it's working well and I haven't felt compelled to roll my own solution.
//rss.js
const writeXMLFeed = () => {
const postMetaData = JSON.parse(fs.readFileSync('src/postMetaData.json'));
const converter = new showdown.Converter();
const feed = new RSS({
title: "SleepyDev's Blog",
description: 'SleepyDev Blog Feed',
feed_url: 'https://sleepydev.neocities.org/rss.xml',
site_url: 'https://sleepydev.neocities.org',
webMaster: 'SleepyDev',
copyright: '2023 SleepyDev',
language: 'en',
ttl: '60',
});
postMetaData.forEach((post) => {
const postContent = fs.readFileSync(`posts/${post.title}.md`, 'utf8');
const contentHTML = converter.makeHtml(postContent);
//strip out the header to avoid RSS readers repeating it
const contentWithoutHeader = contentHTML.split('</h2>')[1];
feed.item({
title: post.title,
description: contentWithoutHeader,
url: `https://sleepydev.neocities.org/posts/${encodeURIComponent(post.title)}.html`,
date: post.pubDate,
});
});
fs.writeFileSync(`site/feed.xml`, feed.xml());
};
While it's a little hacky (my speciality) and clearly not well optimized, the setup has been treating me well. This generation pattern also extends to my microblog, booklog, and short fiction journal. Knowing that this automation will take care of most of the heavy lifting for the content heavy sections of my site encourages me to spend more time focusing on writing and less time grinding out hardcoded markup. π«